這幾天從 Day 16 到 Day 18,我們把 Transformer 的數學公式拆得超細,連帶著整個 BERT 的架構也講得蠻透徹了。現在,是時候來點實作了。你可能不會相信,這次的程式碼簡單到讓你懷疑人生。跟之前一樣,我們不會直接拿 Hugging Face 現成的 sequence classification 模型來用,而是要自己從頭搭一個完整的 BERT 分類器,這樣才學得到東西嘛。
在通訊軟體上你應該也常常看到那種標題超聳動的新聞連結吧?第一眼就被吸住忍不住想點進去看看到底發生什麼事。可是一旦你點了這個行為就會被某些追蹤或推薦系統記錄下來,也正因為這樣,假新聞才會一篇接一篇地被擴散出去。
更有趣的是假新聞在文字上常常會有種說不出的怪感,句子時常誇大,語意也常常偏離正常的用法。這時候我們就可以利用像 BERT 這樣的語意分析模型,來幫助我們做分類,所以現在來看看我們要怎麼進行分類吧。
而在HF中這個分類器的寫法其實蠻直觀的我們只需要把 BERT 的輸出拿來接一層 Dropout 再接 Linear 就好了。具體來說就是抓出 BERT 輸出的 pooled output
(也就是那個 [CLS] token 對應的向量),然後丟進 Dropout 做一點 regularization,最後接一層線性分類器,把它變成我們想要的分類結果。
import torch.nn as nn
from transformers import AutoModel, AutoTokenizer
from transformers.modeling_outputs import SequenceClassifierOutput
class CustomBertForSequenceClassification(nn.Module):
def __init__(self, model_name="bert-base-uncased", num_labels=2):
super().__init__()
self.num_labels = num_labels
self.bert = AutoModel.from_pretrained(model_name)
self.dropout = nn.Dropout(0.1)
self.classifier = nn.Linear(self.bert.config.hidden_size, num_labels)
def forward(self, input_ids=None, attention_mask=None, token_type_ids=None, labels=None):
outputs = self.bert(
input_ids=input_ids,
attention_mask=attention_mask,
token_type_ids=token_type_ids,
return_dict=True
)
pooled_output = outputs.pooler_output
pooled_output = self.dropout(pooled_output)
logits = self.classifier(pooled_output)
loss = None
if labels is not None:
loss_fn = nn.CrossEntropyLoss()
loss = loss_fn(logits.view(-1, self.num_labels), labels.view(-1))
return SequenceClassifierOutput(
loss=loss,
logits=logits,
hidden_states=outputs.hidden_states,
attentions=outputs.attentions
)
model = CustomBertForSequenceClassification("bert-base-uncased", num_labels=2)
tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
而我們整個分類器的輸出最後會包裝成 Hugging Face 官方提供的 SequenceClassifierOutput
這個格式。這個輸出物件裡面會包含幾個東西一個是訓練時用的 loss(如果有給 label 的話就會自動計算),再來是預測結果的 logits,另外還有 attention weights 跟 hidden states,說白了就是把輸出結果包裝起來讓我們更好呼叫罷了。
而我們一開始當然就是從資料讀取點我下載開始囉!這邊我們會用 pandas
來讀兩份 CSV 檔案一份是假的新聞 Fake.csv
,另一份是真的新聞 True.csv
。為了讓模型知道誰真誰假,我們會先各自給它們加上一個欄位 label
,假新聞標 0,真新聞標 1。接著,再把這兩份資料合併起來,變成我們訓練用的完整 dataset,這樣資料前處理的第一步就完成了。
import pandas as pd
from sklearn.model_selection import train_test_split
df_fake = pd.read_csv('Fake.csv')[['text']].assign(label=0)
df_real = pd.read_csv('True.csv')[['text']].assign(label=1)
df_all = pd.concat([df_fake, df_real], ignore_index=True)
x_train, x_valid, y_train, y_valid = train_test_split(
df_all['text'].values,
df_all['label'].values,
train_size=0.8,
random_state=46,
shuffle=True
)
資料切分的部分,我們會用比較標準的做法把整體資料按照 8:2 的比例分成訓練集和測試集,這樣的切法可以幫助我們取得比較穩定、可靠的評估結果。
接下來我們會自定義一個 Dataset 類別,讓 PyTorch 能夠輕鬆讀取我們處理好的資料。這個類別會把每一筆資料的標題或內容和對應的標籤包起來。
import torch
from torch.utils.data import Dataset, DataLoader
class NewsDataset(Dataset):
def __init__(self, texts, labels):
self.texts = texts
self.labels = labels
def __getitem__(self, idx):
return self.texts[idx], self.labels[idx]
def __len__(self):
return len(self.texts)
def news_collate_fn(batch):
texts, labels = zip(*batch)
encoded = tokenizer(
list(texts),
max_length=512,
truncation=True,
padding="longest",
return_tensors='pt'
)
encoded['labels'] = torch.tensor(labels, dtype=torch.long)
return encoded
trainset = NewsDataset(x_train, y_train)
validset = NewsDataset(x_valid, y_valid)
train_loader = DataLoader(
trainset,
batch_size=32,
shuffle=True,
num_workers=0,
pin_memory=True,
collate_fn=news_collate_fn
)
valid_loader = DataLoader(
validset,
batch_size=32,
shuffle=False,
num_workers=0,
pin_memory=True,
collate_fn=news_collate_fn
)
同樣地為了讓 DataLoader 能夠正確處理 batch,我們還會寫一個 collate_fn
函式。這個函式會利用事先載好的 tokenizer,把每一筆文字轉成模型可以吃的格式:像是 input_ids
、attention_mask
等等,同時也會進行 padding,確保每個 batch 的長度一致。這樣處理過後,我們的資料就能夠被順利丟進 BERT 裡跑起來了。
同樣地訓練的部分我們就直接沿用前幾天寫好的訓練器,把整個流程接起來就好。而且別忘了BERT 這種大型預訓練模型,其實在微調任務上收斂得非常快大約 1 到 2 個 epoch 就能有不錯的結果了。所以這邊我們會把 early_stopping
的值調得比較低,可能設個 1 或 2,讓模型只要稍微沒進步就停止訓練,避免過度擬合、也節省時間。整體來說就是讓訓練過程更有效率,畢竟這套模型本身已經夠聰明了。
這邊有一個地方要特別注意一下如果你是用昨天我們那種整套模型架構都自己搬過來的方式,那麼你其實可以不受 BERT 的輸入長度限制,也就是超過 512 tokens 也 OK,因為你可以自行修改 positional embedding 或其他底層設定。
但像我這邊如果是直接用 Hugging Face 提供的 BertModel.from_pretrained
,那就得遵守它的輸入長度限制,最多只能接受 512 個 tokens。這是因為 BERT base 的設計就是在這個長度下預訓練的,超過的話就會出錯或自動截斷掉。
from trainer import Trainer
import torch.optim as optim
optimizer = optim.AdamW(model.parameters(), lr=1e-3)
trainer = Trainer(
epochs=100,
train_loader=train_loader,
valid_loader=valid_loader,
model=model,
optimizer=optimizer,
early_stopping=2,
load_best_model=True,
grad_clip=1.0,
)
trainer.train(show_loss=True)
輸出結果:
Using device: cuda
Train Epoch 0: 100%|██████████| 1123/1123 [05:57<00:00, 3.14it/s, loss=0.725]
Valid Epoch 0: 100%|██████████| 281/281 [00:30<00:00, 9.16it/s, loss=0.689]
Saving Model With Loss 0.69998
Train Loss: 0.71012 | Valid Loss: 0.69998 | Best Loss: 0.69998
看到這裡有沒有覺得整體流程突然變簡單很多?這也就是為什麼這麼多人喜歡用 Hugging Face 的模型架構因為它真的包裝得很完善,從模型、Tokenizer 到訓練工具,幾乎一條龍搞定,對開發者來說非常友善。
但這種幫你都弄好的包裝也不是沒有代價的。過度依賴的情況下,當模型出了問題,你可能根本搞不清楚是哪裡出錯,也不知道該怎麼下手 debug。這也是為什麼我們前幾天花了那麼多時間,一步步講解 Transformer 和 BERT 的內部運作,還帶你自己動手搭建架構就是希望你不只是「會用」,而是「真的懂」。這樣一來不管你未來想改模型、優化結構,還是針對特定任務調整設計,你都能游刃有餘,不會被框死在現成工具的限制裡。
OK,Transformer Encoder的部分我們已經打好了穩固的基礎,明天就要進入全新的主題:Transformer Decoder。而且接下來幾天,我們也會開始介紹一些 Decoder-only 的預訓練模型,像是 GPT 這類的架構。
不過別擔心後面的章節不會再像前面那樣塞滿一堆數學公式了。為什麼?因為你該學的數學基礎,其實我們在講 Transformer Encoder 的時候早就打過一輪了。注意力機制、位置編碼、殘差結構、LayerNorm……那些核心元素你都已經接觸過。
這也是我之前一直強調的:當你真正理解 Transformer,是 Encoder 也好、Decoder 也罷,甚至 GPT、BART、T5 這些變形體,其實也就差不多懂了。 後面更多是結構上的變化與任務上的調整,而不是概念上的大轉彎。總之明天我們就正式開始學習 Decoder 吧